Skip to content

fix(fonts): supplement alias faces from the canonical family - #3085

Open
akzarma wants to merge 3 commits into
heygen-com:mainfrom
akzarma:fix/alias-supplement-canonical-family
Open

fix(fonts): supplement alias faces from the canonical family#3085
akzarma wants to merge 3 commits into
heygen-com:mainfrom
akzarma:fix/alias-supplement-canonical-family

Conversation

@akzarma

@akzarma akzarma commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What breaks

A family that resolves through FONT_ALIAS_MAP to a bundled canonical can end up with two different typefaces under one font-family.

font-family: Helvetica with any italic text renders upright as Inter (the canonical bundle) and italic as real Helvetica (fetched from Google under the authored name). Same for Noto Sans, Georgia, Verdana, Garamond and the other cross-typeface aliases — including three that typography.md advertises as safe.

Details, measurements and the alias-by-alias table are in #3083.

Root cause

packages/producer/src/services/deterministicFonts.ts, in buildFontFaceCss:

const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText);

The embedded canonical's faces are emitted under the authored family name, and then the weights/styles the bundle lacks are supplemented by querying Google — with the authored name. For a self-referencing alias (montserrat → Montserrat) that is correct. For a cross-typeface alias it fetches the very typeface the alias exists to replace.

Two properties make it bite rather than stay theoretical:

  • No CANONICAL_FONTS entry declares style: "italic", so every italic Google serves for the authored name is classified "missing from the bundle" and injected.
  • Google's css2 endpoint now serves many of these names (Helvetica, Helvetica Neue, Georgia, Verdana, Tahoma, Trebuchet MS, Garamond, Noto Sans, …), so the comment at fetchGoogleFont assuming alias names 4xx is no longer true for them.

The fix

Query CANONICAL_FONT_DISPLAY_NAMES[canonicalKey] instead of the authored name. It is already re-exported from @hyperframes/core/fonts/aliases, which this file imports.

Faces are still emitted under originalCaseFamily, so authored CSS keeps matching and nothing about the aliasing policy changes — only the source the supplementary faces come from.

Test

deterministicFonts-aliasSupplement.test.ts — hermetic, injects fetchImpl, no network:

  • Noto Sans → asserts the supplementary query asks for Inter, that the authored spelling still names the family, and that every injected src is Inter (embedded bundle or Inter fetch) with no real Noto Sans bytes present.
  • Montserrat → asserts self-referencing aliases still supplement from their own family (guards against over-correcting).

Verified the test bites: against unfixed deterministicFonts.ts it is 1 pass / 1 fail; with the fix, 2 pass / 0 fail.

Test plan

  • bun test packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts → 2 pass, 0 fail
  • Same suite against unfixed source → 1 fail, confirming the regression test bites
  • oxfmt --check and oxlint on both changed files → clean, 0 warnings / 0 errors
  • Full packages/producer unit lane not run — it needs workspace packages built beyond what a fresh clone provides; relying on CI for that

`buildFontFaceCss` emits a bundled canonical's faces under the authored
family name, then fills the weights and styles the bundle lacks by
querying Google Fonts. That supplementary query used the authored name.

For a cross-typeface alias — `helvetica`, `noto sans`, `georgia` and the
other FONT_ALIAS_MAP entries that do not point at themselves — the
authored name is a different typeface from the canonical the alias
resolves to, and Google now serves many of those names. No canonical
bundle ships an italic face, so every italic Google returns for the
authored name is injected: `font-family: Helvetica` renders upright as
Inter and italic as real Helvetica, two typefaces under one family.

Query the canonical display name instead. The faces are still emitted
under the authored family, so authored CSS keeps matching, and
self-referencing aliases are unaffected.

Closes heygen-com#3083

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced the bug against live Google Fonts, confirmed this branch fixes it, and found one thing I'd like addressed before it lands.

Repro (main, unfixed)

font-family: Helvetica, upright + italic text, real network:

google css2 family= queried: [ "Helvetica" ]
  Helvetica  normal 400  EMBEDDED INTER BUNDLE
  Helvetica  normal 700  EMBEDDED INTER BUNDLE
  Helvetica  normal 900  EMBEDDED INTER BUNDLE
  Helvetica  italic 400  FETCHED FROM GOOGLE
  Helvetica  italic 700  FETCHED FROM GOOGLE

Rendered headless, the upright line is Inter and the italic line is Google's Helvetica substitute. Two typefaces under one font-family, exactly as described. css2?family=Helvetica returns 200 today, so the "alias names 4xx" comment is indeed stale.

With this branch

google css2 family= queried: [ "Inter" ]
  ... italic 400 / 700 now come from Inter

Italic renders as Inter Italic. Bug is gone, and weights 100-800 that previously had no face at all now resolve to real Inter weights. The fix is at the right layer, in the one function every aliased family routes through, and reusing CANONICAL_FONT_DISPLAY_NAMES is the right call: all 18 canonical slugs have an entry and every one is a real Google family.

Blocking: the payload multiplies

Google serves Inter as a variable font, so css2 hands back the same woff2 URL for every static weight. The supplement loop embeds that identical base64 blob once per weight.

One family, Helvetica, compiled HTML:

faces unique blobs HTML
main 5 5 110 KB
this branch 11 5 302 KB

Six @font-face rules (100/200/300/500/600/800) carry byte-identical copies of one 25 KB blob; the two italics are another duplicate pair.

It compounds across aliases, because names that used to 4xx now fetch. Four families that all resolve to Inter (Helvetica, Arial, SF Pro, Verdana):

faces unique blobs HTML
main 16 7 405 KB
this branch 44 5 963 KB

963 KB carrying five distinct fonts. The duplication is pre-existing in the supplement loop, but this PR is what makes it fire on every cross-typeface alias, so I'd rather not land the amplification untouched.

Smallest fix that keeps semantics: when consecutive Google faces share a src, emit one rule with a weight range (font-weight: 100 800) instead of one rule per weight. That is the correct declaration for a variable font anyway and takes the 4-family case back under 300 KB. If you'd rather keep this PR to the one-line correctness fix, that's fine by me, but please open the follow-up and link it here.

Non-blocking

  1. ?? originalCaseFamily is unreachable today, and if it ever becomes reachable it silently restores this exact bug. Worth a line in deterministicFonts.test.ts asserting every CANONICAL_FONTS key has a CANONICAL_FONT_DISPLAY_NAMES entry, then the fallback can go.
  2. resolveAliasDisplayName() already exists in the same module and does this lookup in one call. Would drop the extra import. Pure taste, ignore if you prefer the explicit map.

Test file looks right to me. It classifies into the unit/bun lane with no manifest edit needed, and the assertions do bite: on unfixed source queriedFamilies is ["Noto Sans"], so the first test fails and the Montserrat guard passes. Matches your 1 pass / 1 fail.

Google serves several canonical families as a variable font, so every
static weight in the css2 response points at the same woff2. Emitting
one rule per weight embedded that identical blob once per weight, which
this branch made fire on every cross-typeface alias.

Collapse a consecutive run of supplementary faces sharing a src, style
and unicode-range into a single weight-range rule, which is also the
correct declaration for a variable font. A run stops at any weight the
embedded bundle already covers, so a range can never shadow a bundled
face, and the pair is sorted low-to-high rather than trusting Google's
ordering.

Also resolve the canonical family through `resolveAliasDisplayName` and
drop the `?? originalCaseFamily` fallback: when resolution fails the
supplement is now skipped instead of silently querying the authored
family again. A test asserts every alias resolves, so the branch is
provably unreachable.
@akzarma

akzarma commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@miguel-heygen Thanks for the repro and the measurements — the amplification is fair, and I took the weight-range route rather than deferring it.

Consecutive supplementary faces sharing a src, style and unicode-range now collapse into one rule. A run stops at any weight the embedded bundle covers, so a range can't shadow a bundled face — my first attempt emitted font-weight: 100 500 straight across a bundled 400, which the new test caught. The pair is also sorted low-to-high rather than trusting Google's ordering, and a non-numeric or variable-range weight value refuses to collapse rather than guessing.

Both non-blocking notes taken: it uses resolveAliasDisplayName now, and the ?? originalCaseFamily fallback is gone — resolution failure skips the supplement instead of silently re-querying the authored family, with a test asserting every alias resolves so the branch is unreachable.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R2 at 1a87dcb02. The core fix is valid: resolving the supplementary query through the canonical display name at packages/producer/src/services/deterministicFonts.ts:531-534 eliminates the real mixed-typeface output, and the positive assertions at packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts:111-132 pin both the query family and injected bytes. The earlier resolver/fallback concerns are also cleanly closed.

blocker — the payload collapse does not handle Google’s real no-text= response shape. packages/producer/src/services/deterministicFonts.ts:545-557 only collapses consecutive faces with the same source/style/unicode-range. When extractGoogleFontsText exceeds its 1,700-byte budget and returns undefined (packages/producer/src/services/deterministicFonts.ts:1130-1143), Google returns the normal subset CSS ordered by weight, then unicode subset: 100/latin, 100/latin-ext, 200/latin, 200/latin-ext, etc. A variable font reuses the same source per subset across weights, but those matching faces are never adjacent, so every base64 blob is still emitted once per weight.

I reproduced this against the current code with a hermetic real-shape response: two unicode subsets × weights 100/200/300/500, one shared source per subset, and HTML large enough to omit text=. Expected four emitted blobs (100–300 + 500 for each subset); head emitted eight. The existing test at packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts:135-169 has one subset only, so it cannot falsify this path.

Please group supplementary faces by (dataUri, style, unicodeRange), numerically sort each group, then partition ranges around covered embedded weights; add the interleaved-subset/no-text= case. That also makes the “Google ordering is not guaranteed” claim true structurally instead of relying on adjacency.

Exact-head unit/build/lint lanes are green and the touched suite passes 4/4 locally, but the exact-head regression workflow is also red on style-15-prod visual comparison; rerun or resolve that before the next verdict.

Verdict: REQUEST CHANGES
Reasoning: The alias correctness fix is real and well placed, but the requested payload guard still misses a normal production response path and leaves the amplification intact for larger compositions.

— Magi

Without `text=` Google orders the response weight-major, subset-minor,
so faces sharing a variable font's source are never adjacent and the
previous consecutive-run scan collapsed nothing: every blob was still
embedded once per weight.

Group supplementary faces by (source, style, unicode-range), sort each
group numerically, and split it wherever the embedded bundle already
covers a weight inside the span. Each collapsed run is emitted at the
position of its first face in the response, because overlapping
unicode-range rules resolve last-defined-first and collapsing must not
reorder the subsets. Coverage keys are compared numerically so a
differently spelled weight cannot slip past and shadow a bundled face.

Tests cover the no-`text=` interleaved shape and the overlapping-subset
ordering; both fail against the previous implementation.
@akzarma

akzarma commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

@miguel-heygen R3 pushed — grouped by (source, style, unicode-range) with runs split at covered weights, each run emitted at its first face's position so overlapping unicode-range order survives collapsing, and coverage keys compared numerically. Tests cover the no-text= interleaved shape and the subset ordering; both fail against the previous implementation.

One flag on the ranges: for a genuine variable font, font-weight: 100 300 means an intermediate 150 interpolates rather than clamping. Correct declaration, but a rendering change — better stated than found.

On the red style-15-prod: its baseline looks like it encodes the bug. The recorded output/compiled.html has, under one font-family: "Helvetica", normal 400/700/900 from the @fontsource/inter bundle and italic 400/700 fetched from Google — same shape for "Helvetica Neue". That's the mixing this PR removes, so the render diverges from the recorded video (43/100 checkpoints, concentrated from ~7.7s where the italics appear). The collapse isn't a factor there — every face in that fixture has a distinct source, so nothing collapses. I don't have render infra to re-record it; happy to if you point me at the command.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 4900b2b3, additive to Magi's two rounds rather than a fresh pass. Magi's R2 is pinned to 1a87dcb0, which is two commits stale, so the useful questions are (a) is the R2 blocker actually closed at this head and (b) is the red required check what the author says it is. Both answers below, and the second one is the reason I am not stamping.

Magi's R2 blocker is closed

R2 asked for grouping by (dataUri, style, unicodeRange), numeric sort, and partitioning around covered embedded weights. That is what 4900b2b3 does:

  • groupFacesBySource (deterministicFonts.ts:478-487) keys on exactly [dataUri, style, unicodeRange ?? ""], so non-adjacent faces sharing a source now group. This is the specific thing the adjacency version could not do on the no-text= interleaved response.
  • partitionWeightRuns (:518-538) sorts numerically before walking, and breaks a run via spansCoveredWeight (:493-510) whenever a bundled weight falls strictly inside the span.
  • Emission order is preserved by firstAppearance (:593-595), which re-sorts the collapsed runs back to their position in the response. That matters for overlapping unicode-range, which resolves last-defined-first, and it is the part I would most have expected a collapse to get wrong.

Worked through the concrete case: bundle covers 400/700 normal, Google returns 100/200/300/500/600/800/900 on one source. Runs come out 100 300, 500 600, 800 900. No range spans 400 or 700, so no collapsed rule shadows a bundled face. Strict inequality in spansCoveredWeight is right rather than off-by-one, because supplementary has already filtered out every covered (weight, style), so a face's own weight can never be the covered one.

Both R1 non-blocking items are also done: resolveAliasDisplayName replaces the explicit map lookup, and the ?? originalCaseFamily fallback is gone. On the fallback specifically, the new behaviour is the safe direction. A resolution failure now skips supplementation entirely instead of silently re-querying the authored family, so the failure mode is missing weights rather than a reintroduced typeface mix. Test 5 pins it, and it pins the right map: FONT_ALIASES at :378 is FONT_ALIAS_MAP with a cast, the same object the branch reads at :554, so there is no map-divergence gap between what the test iterates and what the code dispatches on.

Both fetchGoogleFont callsites are correct at this head: :578 queries the canonical for the aliased path, :614 queries the authored name for Path 2, which is right because an unaliased family should be fetched under its own name.

The red regression check: the author's explanation is correct, and here is the proof

regression is one of the 8 required contexts on main, and it is failing at this head (regression-shards (shard-9, style-15-prod ...)). Several sibling shards read cancelled, which is fail-fast fallout and inconclusive, not red.

The author states the style-15-prod baseline encodes the bug. I checked rather than took it. Extracting every @font-face from the recorded packages/producer/tests/style-15-prod/output/compiled.html and hashing each base64 payload gives 14 faces over 8 distinct blobs:

family weight style blob
Helvetica 400 / 700 / 900 normal BLOB1 / BLOB2 / BLOB3
Arial 400 / 700 / 900 normal BLOB1 / BLOB2 / BLOB3
Helvetica Neue 400 / 700 / 900 normal BLOB1 / BLOB2 / BLOB3
Helvetica 400 / 700 italic BLOB4 / BLOB5
Helvetica Neue 400 / 700 italic BLOB6 / BLOB7
Helvetica Neue 300 normal BLOB8

Three separate authored families share byte-identical upright blobs, which is the embedded Inter bundle resolving correctly through the alias. The clincher is the italics: Helvetica italic and Helvetica Neue italic are different blobs from each other. Both families alias to Inter, so if either had been supplemented from the canonical they would be the same bytes. They are not, because each was fetched under its own authored name. Two typefaces under one family name, exactly as #3083 describes, recorded into the baseline.

Helvetica Neue normal 300 as its own unique blob is the same story and independently corroborates the issue's table, which lists helvetica neue → inter injecting precisely 300, 400i, 700i.

I confirmed the baseline I measured is current: git hash-object on that file is 7926829bc40225df3490ba15f077bed975d48daf, identical to the blob SHA the contents API reports for it on main.

So the failure is the fixture asserting the old, wrong output. 43/100 checkpoints diverging from around 7.7s, where the italics enter, is consistent with that and not with a broader rendering regression. The collapse logic is not implicated either way here, since every face in that fixture has a distinct source and nothing collapses.

What this needs, and why it is not the contributor's to do

The consequence is that this PR cannot go green on its own. style-15-prod has to be re-recorded against the corrected fonts, and the author has already said they lack the render infra and offered to do it given the command. That is a maintainer action, so this is blocked on us rather than on them, and it has been sitting since 10 Aug.

Worth being explicit for whoever picks that up: re-recording is not a formality here. It is accepting a deliberate visual change to that fixture, where italic text stops being Helvetica and becomes Inter Italic. That is the intended correction, but it should be an eyes-on approval of the new video, not a blind re-record.

One thing worth a maintainer's eye

The author flagged, unprompted, that font-weight: 100 300 on a genuine variable font means an intermediate weight interpolates rather than snapping to a discrete face. That is the correct declaration for a variable font and I agree with the direction, but it is a real rendering change for any composition currently requesting an off-step weight, and no test pins it. Neither review has responded to that point. I would not block on it, but it belongs in the merge note rather than only in a PR comment.

Verdict

Not approving, on a single mechanical ground: a required check is red. regression is required on main and it is failing at this head, and I do not stamp over red required CI even when I believe I know why it is red. I have said above why I think the red is a stale baseline rather than a defect, and if a maintainer re-records style-15-prod and regression goes green, I have no other blocker on this PR at 4900b2b3.

Also noting for the record that Magi's CHANGES_REQUESTED is still the live review decision and is pinned to 1a87dcb0. On my read the blocker it names is addressed at this head, but that is Magi's to clear, not mine to overrule.

Separately: this is an unusually good bug report. The 51-alias enumeration, the live measurement of which 19 actually mix today, and the point that the set will drift with Google's catalog rather than with our code are all things that made this reviewable without re-deriving the analysis.

— Rames Jusso (James's assistant)

@jrusso1020

Copy link
Copy Markdown
Collaborator

@akzarma — I have carried your fix into #3230 with the re-recorded baseline attached, and I want to explain why rather than just closing this, because the reason is entirely on our side.

Your fix is unchanged and it is still your commit, authored to you. I added exactly one commit on top: the style-15-prod baseline.

Why this needed taking over. You were right that the style-15-prod baseline encodes the bug — I verified it independently before touching anything. The recorded compiled.html has Helvetica italic and Helvetica Neue italic as different bytes despite both aliasing to Inter, and Helvetica Neue 300 as genuine Helvetica Neue. So the corrected render legitimately diverges from it, exactly as you said, and the baseline has to be re-recorded in the same change.

You offered on 10 Aug to do that if someone pointed you at the command, and nobody answered you. That was our failure, not yours. When I went to push the re-record to your branch instead, it turned out we cannot: the baseline is a 22 MB LFS object, and maintainer_can_modify grants ref push but not write to a fork's LFS store. The only alternatives were to open a PR into your branch and make you wait on us again, or to carry it. Given you had already absorbed a delay from us, carrying it seemed the less rude option. If you would rather have merged it yourself, say so and I will happily do it that way instead.

Two things I found while verifying, which are worth you knowing because they make your fix look better than the report claimed.

First, the mixing was never confined to italics. I extracted the failing frames, and upright text changes too — visible from 2.51s, five seconds before any italic renders. That is weight 300 on Helvetica Neue, which the bundle does not ship, so it was being fetched under the authored name. Your issue's own table said exactly this (helvetica neue → inter injecting 300, 400i, 700i) and every one of us, me included, read the 400i, 700i and skipped the 300.

Second, measured on that fixture, main today versus your branch today:

file faces distinct typefaces
main 922 KB 25 10
your fix 1186 KB 28 5

Four families that all alias to Inter currently render as ten different typefaces. After your fix, five, and all four families resolve to byte-identical face sets. That is the clearest statement of the bug I could find, and it is your fix that produces it.

On the payload question Magi raised: the honest number is +29%, not the doubling I first calculated. My initial figure compared against a baseline recorded in May that had drifted for unrelated reasons — a mistake of exactly the kind your report was careful to avoid.

Verification before re-recording, so nobody has to take the new baseline on trust: the divergence is text-only (every changed pixel is a glyph outline, zero changed pixels outside two horizontal bands), it reproduces your 43-of-100 checkpoints with first failure at 7.68s, and the recording validates in distributed-simulated — the only mode CI runs this fixture in — at 0 failed frames, PSNR 37-47 against a minPsnr of 30.

This was an unusually good bug report: the 51-alias enumeration, the live measurement of which 19 actually mix, and the point that the set drifts with Google's catalogue rather than with our code. Thank you, and sorry for the wait on the re-record.

Leaving this open rather than closing it myself — that call belongs to a maintainer, and #3230 credits you either way.

— Rames Jusso (James's assistant)

jrusso1020 added a commit that referenced this pull request Aug 11, 2026
A family resolving through FONT_ALIAS_MAP could emit @font-face rules drawn from two
unrelated typefaces under one font-family name, split by weight and style. The
supplementation fetch was passed the authored name, so for a cross-typeface alias
(helvetica -> inter) it asked Google for the very typeface the alias exists to replace.

Diagnosed, reported and fixed by Akshay Kumar Sharma (@akzarma) in #3083 / #3085. This
PR carries that work because the fix requires re-recorded regression baselines, which
are LFS objects we cannot push to a fork's LFS store.

Baselines re-recorded for style-15-prod and style-3-prod, each verified text-only
before acceptance. All 9 regression shards pass.

Closes #3083.

Co-authored-by: Akshay Kumar Sharma <25038017+akzarma@users.noreply.github.com>
@jrusso1020

Copy link
Copy Markdown
Collaborator

@akzarma — your fix is merged, as a16222d9a on main. Thank you for this one.

Your commit went in authored to you, and the squash carries a Co-authored-by trailer so it lands on your contribution graph. #3083 closed as completed.

Two things your report turned out to be right about that I want on the record, since both took verification to confirm:

  • The baseline really did encode the bug. style-15-prod's recorded compiled.html had Helvetica italic and Helvetica Neue italic as different bytes despite both aliasing to Inter. So the corrected render legitimately diverged from it, exactly as you said.
  • A second fixture needed the same treatment. style-3-prod uses the same families and was caught on CI after the first baseline landed. Re-recorded too. I then swept every other fixture referencing these families rather than finding them one failed CI run at a time, and those two were the complete set.

For the number in your report: measured properly, the payload grows 29%, not the doubling I first quoted here. My initial figure compared against a months-old baseline that had drifted for unrelated reasons, which is the exact mistake your report was careful not to make.

Leaving this PR open rather than closing it myself, since that call belongs to a maintainer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants